﻿using System;
using System.Diagnostics;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using NAnt.Core;
using NAnt.Core.Attributes;
using NAnt.Core.Types;
using Amazon.S3;
using System.IO;
using Amazon.S3.Model;
using System.Net;

namespace NAntExt.Aws.Tasks
{
    /// <summary>
    /// Mirrors a folder from local to S3.
    /// </summary>
    [TaskName(S3MirrorTask.TaskName)]
    public class S3MirrorTask : AwsTaskBase
    {
        /// <summary>
        /// The name of this task.
        /// </summary>
        public const string TaskName = "s3-mirror-to";

        /// <summary>
        /// The AWS bucket to use to access the files and folders. This will need to exist on S3.
        /// </summary>
        [TaskAttribute("bucket", Required = true)]
        [StringValidator(AllowEmpty = false)]
        public string BucketName { get; set; }

        private FileSet _fromFileSet = new FileSet();

        /// <summary>
        /// The files or directories to mirror to the S3 bucket. 
        /// </summary>
        [BuildElement("from", Required = true)]
        public virtual FileSet FromFileSet
        {
            get
            {
                return _fromFileSet;
            }
            set
            {
                _fromFileSet = value;
            }
        }

        /// <summary>
        /// The destination S3 path to mirror from the <c>fromDir</c>. This should be a path in the S3 bucket. 
        /// Example /foldername/subfoldername/filename.extension.
        /// </summary>
        [TaskAttribute("path", Required = true)]
        [StringValidator(AllowEmpty = false)]
        public string Path { get; set; }

        /// <summary>
        /// Determines whether or not directories included in the <c>from</c> fileset will be recursively
        /// mirrored or not. If <c>true</c>, then any directory will be recursive, this can be dangerous
        /// if you include patterns which include parent and children directories as the operation will
        /// waste time mirroring already mirrored directories.
        /// </summary>
        [TaskAttribute("recursive", Required = false)]
        [BooleanValidator()]
        public bool Recursive { get; set; }

        private bool _forceLowerCase = true;

        /// <summary>
        /// Forces all files and directories to be lower case when pushing to S3. S3 is case sensitive
        /// and therefore, it's useful to have a standard way of case sensitivity for windows users.
        /// Defaults to true.
        /// </summary>
        [TaskAttribute("forceLowerCase", Required = false)]
        [BooleanValidator()]
        public bool ForceLowerCase
        {
            get
            {
                return _forceLowerCase;
            }
            set
            {
                _forceLowerCase = value;
            }
        }

        /// <summary>
        /// The file the updated keys file will be written to as updates are made.
        /// </summary>
        [TaskAttribute("outputUpdatedKeysFile", Required = false)]
        [StringValidator(AllowEmpty = false)]
        public string OutputUpdatedKeysFile { get; set; }

        /// <summary>
        /// The number of files skipped during the execution.
        /// </summary>
        private int SkippedFileCount;

        /// <summary>
        /// The number of files created on S3 during the execution.
        /// </summary>
        private int CreatedFileCount;

        /// <summary>
        /// The number of files updated on S3 during the execution.
        /// </summary>
        private int UpdatedFileCount;

        /// <summary>
        /// The number of files removed from S3 during the execution.
        /// </summary>
        private int DeletedFileCount;

        /// <summary>
        /// Primary entry point for this task.
        /// </summary>
        protected override void ExecuteTask()
        {
            try
            {
                // track stats of the mirror process
                var sw = new Stopwatch();
                sw.Start();
                CreatedFileCount = 0;
                UpdatedFileCount = 0;
                DeletedFileCount = 0;
                SkippedFileCount = 0;

                // trim the path so it doesn't include the path even though we want the users to
                // explicitly put one in to indicate they want to mirror the root folder
                var toS3Path = this.Path.TrimStart('/');

                using (var client = new Amazon.S3.AmazonS3Client(AccessKeyId, SecretAccessKey))
                {

                    // validate the bucket exists
                    if (!S3Bucket.Exists(client, this.BucketName))
                    {
                        throw new System.ArgumentOutOfRangeException("bucketName", this.BucketName, "Bucket does not exist on Amazon S3. Note the bucket name is case sensitive.");
                    }

                    // iterate over the directories
                    foreach (var directoryName in _fromFileSet.DirectoryNames)
                    {
                        MirrorDirectoryToS3(client, new DirectoryInfo(directoryName), ToS3Path(directoryName), this.Recursive);
                    }

                    // iterate over the files
                    foreach (var fileName in _fromFileSet.FileNames)
                    {
                        MirrorFileToS3(client, new FileInfo(fileName), ToS3Path(fileName));
                    }

                }

                // stop the timer
                sw.Stop();

                Log(Level.Info, "Finished mirroring to S3. Elapsed Time: {0} Created: {1} Updated: {2} Deleted: {3} Skipped: {4}", sw.Elapsed, CreatedFileCount, UpdatedFileCount, DeletedFileCount, SkippedFileCount);

            }
            catch (Exception ex)
            {
#if DEBUG
                System.Diagnostics.Debug.WriteLine(ex, "Error");
#endif

                if (this.FailOnError)
                    throw new BuildException(string.Format("{0} task failed during execution due to an unhandled exception.", TaskName), this.Location, ex);
                else
                    Log(Level.Error, "{0} task failed during execution due to an unhanled exception. ex = {0}", TaskName, ex);

            }
        }
        
        /// <summary>
        /// Mirrors a local directory to an S3 bucket.
        /// </summary>
        /// <param name="client">A valid AWS S3 client.</param>
        /// <param name="directoryName">The directory to mirror.</param>
        /// <param name="directoryS3Path">The S3 path for the directory.</param>
        /// <param name="recursive">Indicate whether or not the operation is recursive.</param>
        private void MirrorDirectoryToS3(AmazonS3Client client, DirectoryInfo directory, string directoryS3Path, bool recursive)
        {
            if (client == null)
            {
                throw new System.ArgumentNullException("client");
            }

            if (directory == null)
            {
                throw new System.ArgumentNullException("directory");
            }

            if (directoryS3Path == null)
            {
                throw new ArgumentNullException("directoryS3Path");
            }

            // make a note of the directory
            LogAction("Scanning", directory.FullName, directoryS3Path);

            // get the files within this directory so we can send them if needed or remove legacy ones from s3
            var localFiles = new List<FileInfo>(directory.GetFiles());
            var localSubDirectories = new List<DirectoryInfo>(directory.GetDirectories());

            // get the names of the files in s3 so we can remove legacy ones
            var listObjResult = client.ListObjects(new Amazon.S3.Model.ListObjectsRequest()
            {
                BucketName = this.BucketName,
                Delimiter = "/",
                Prefix = (string.IsNullOrEmpty(directoryS3Path)) ? string.Empty : directoryS3Path + "/"
            });

            foreach (string s3SubDirectory in listObjResult.CommonPrefixes)
            {
                var s3LocalSubDirectory = ToLocalPath(s3SubDirectory);
                if (!localSubDirectories.Exists(localSubDirectory => string.Equals(localSubDirectory.FullName, s3LocalSubDirectory, StringComparison.InvariantCultureIgnoreCase)))
                {
                    DeleteDirectoryFromS3(client, new DirectoryInfo(s3LocalSubDirectory), s3SubDirectory);
                }
            }

            foreach (S3Object s3File in listObjResult.S3Objects)
            {
                var s3LocalFile = ToLocalPath(s3File.Key);
                if (!localFiles.Exists(localFile => string.Equals(localFile.FullName, s3LocalFile, StringComparison.InvariantCultureIgnoreCase)))
                {
                    LogAction("Removing", s3LocalFile, s3File.Key);
                    S3File.Delete(client, this.BucketName, s3File.Key);

                    DeletedFileCount++;
                }
            }

            // update s3 with all the existing files
            foreach (var localFile in localFiles)
            {
                var fileS3Path = ToS3Path(localFile.FullName);
                // see if the file exists in the result we got back about this directory
                var fileS3Object = listObjResult.S3Objects.Find(obj => string.Equals(obj.Key, fileS3Path, StringComparison.InvariantCultureIgnoreCase));
                if (fileS3Object == null)
                {
                    // it didn't exist so lets send it
                    LogAction("Creating", localFile.FullName, fileS3Path);
                    S3File.Upload(client, this.BucketName, fileS3Path, localFile);
                    CreatedFileCount++;
                }
                else 
                {
                    // it exists
                    bool uploadFile = true;
                   
                    if (fileS3Object.Size == localFile.Length)
                    {
                        // the sizes match but lets ensure it's the same file
                        var fileS3File = S3File.Get(client, this.BucketName, fileS3Path);

                        // if the file equals both in size and dates then the file already exists
                        uploadFile = !fileS3File.Equals(localFile);
                    }

                    if (uploadFile)
                    {
                        // the file needs to be updated

                        LogAction("Updating", localFile.FullName, fileS3Path);
                        S3File.Upload(client, this.BucketName, fileS3Path, localFile);
                        UpdatedFileCount++;

                        // note it in the updated key file
                        WriteUpdatedKey(fileS3Path);
                    }
                    else
                    {
                        // the file can be skipped because it's already up-to-date
                        LogAction("Skipped", localFile.FullName, fileS3Path);
                        SkippedFileCount++;
                    }
                }
            }

            if (recursive)
            {
                // recursively iterate through the sub-directories and mirror them to S3
                foreach (var subDirectoryName in localSubDirectories)
                {
                    MirrorDirectoryToS3(client, subDirectoryName, ToS3Path(subDirectoryName.FullName), recursive);
                }
            }
        }

        /// <summary>
        /// Writes an updated key to <c>OutputUpdatedKeysFile</c>.
        /// </summary>
        /// <param name="key"></param>
        private void WriteUpdatedKey(string key)
        {
            if (!string.IsNullOrEmpty(OutputUpdatedKeysFile))
            {
                File.AppendAllText(this.OutputUpdatedKeysFile, key + System.Environment.NewLine);
            }
        }

        /// <summary>
        /// Deletes a directory from S3. In effect, this really just recursively iterates through the prefixes
        /// looking for objects and removes all of the objects found. This in effect will remove the prefixes.
        /// </summary>
        /// <param name="client">The S3 client.</param>
        /// <param name="directory">The local path even though it might not exist.</param>
        /// <param name="directoryS3Path">The S3 path for the directory.</param>
        private void DeleteDirectoryFromS3(AmazonS3Client client, DirectoryInfo directory, string directoryS3Path)
        {
            if (client == null)
            {
                throw new System.ArgumentNullException("client");
            }

            if (directoryS3Path == null)
            {
                throw new ArgumentNullException("directoryS3Path");
            }
            else if (directoryS3Path == string.Empty)
            {
                throw new ArgumentOutOfRangeException("directoryS3Path", "Value cannot be empty.");
            }

            // get the names of the files in s3 so we can remove legacy ones
            var listObjResult = client.ListObjects(new Amazon.S3.Model.ListObjectsRequest()
            {
                BucketName = this.BucketName,
                Delimiter = "/",
                Prefix = (string.IsNullOrEmpty(directoryS3Path)) ? string.Empty : directoryS3Path.TrimEnd('/') + "/"
            });

            foreach (string s3SubDirectory in listObjResult.CommonPrefixes)
            {
                var s3LocalSubDirectory = ToLocalPath(s3SubDirectory);
                DeleteDirectoryFromS3(client, new DirectoryInfo(s3LocalSubDirectory), s3SubDirectory);
            }

            foreach (S3Object s3File in listObjResult.S3Objects)
            {
                var s3LocalFile = ToLocalPath(s3File.Key);
                LogAction("Removing", s3LocalFile, s3File.Key);
                S3File.Delete(client, this.BucketName, s3File.Key);

                DeletedFileCount++;
            }
        }
        
        /// <summary>
        /// Mirrors an individual file to S3. It will skip it if it determines it's the same.
        /// </summary>
        /// <param name="client">The S3 client.</param>
        /// <param name="localFile">The local file to upload.</param>
        /// <param name="fileS3Path">The remote S3 path to place it.</param>
        private void MirrorFileToS3(AmazonS3Client client, FileInfo localFile, string fileS3Path)
        {
            if (client == null)
            {
                throw new System.ArgumentNullException("client");
            }

            if (fileS3Path == null)
            {
                throw new ArgumentNullException("fileS3Path");
            }
            else if (fileS3Path == string.Empty)
            {
                throw new ArgumentOutOfRangeException("fileS3Path", "Value cannot be empty.");
            }

            if (localFile == null)
            {
                throw new System.ArgumentNullException("localfile");
            }
            
            var fileS3File = S3File.Get(client, this.BucketName, fileS3Path);
            if (fileS3File == null)
            {
                LogAction("Creating", localFile.FullName, fileS3Path); 
                S3File.Upload(client, this.BucketName, fileS3Path, localFile);
                CreatedFileCount++;
            }
            else if (fileS3File.Equals(localFile))
            {
                LogAction("Skipped", localFile.FullName, fileS3Path);
                SkippedFileCount++;
            }
            else
            {
                LogAction("Updating", localFile.FullName, fileS3Path);
                S3File.Upload(client, this.BucketName, fileS3Path, localFile);
                UpdatedFileCount++;

                // note it in the updated key file
                WriteUpdatedKey(fileS3Path);
            }
        }
        
        /// <summary>
        /// Converts an S3 path to a local path.
        /// </summary>
        /// <param name="s3Path">The S3 path to convert.</param>
        /// <returns>Returns the local path relative to the S3 path.</returns>
        private string ToLocalPath(string s3Path)
        {
            return System.IO.Path.Combine(_fromFileSet.BaseDirectory.FullName, S3Path.ToFileSystemPath(s3Path)).TrimEnd('\\');
        }

        /// <summary>
        /// Converts the file system path to the remote S3 path.
        /// </summary>
        /// <param name="fileSystemPath"></param>
        /// <returns>Returns the S3 path.</returns>
        private string ToS3Path(string localPath)
        {
            string s3Path = S3Path.FromFileSystemPath(localPath.Replace(_fromFileSet.BaseDirectory.FullName, ""));
            if (ForceLowerCase)
            {
                return s3Path.ToLower();
            }
            else
            {
                return s3Path;
            }
        }
        
        /// <summary>
        /// Logs in the standard way an action performed on a file.
        /// </summary>
        private void LogAction(string action, string localPath, string s3Path)
        {
            Log(Level.Info, "{0:,8}\t{1}\t{2}", action, localPath, s3Path);
        }
    }
}
